#!/usr/bin/env python
#--coding=utf-8--
"""
自动部署工具， 自动从gitlab拉取最新的代码，并执行一些初始化命令
author: ksc (http://blog.geekli.cn)
"""
import argparse

import configparser
from urllib.parse import quote
import logging
import os
import subprocess
import sys
import time
import json
from colorama import Fore
from pprint import pprint


import requests
from git import Repo


"""

依赖：
pip install gitpython requests

参考：
https://gitpython.readthedocs.io/en/stable/tutorial.html
https://python-gitlab.readthedocs.io/en/stable/api-usage.html
https://docs.gitlab.com/ce/api/commits.html
https://www.jianshu.com/p/477de2f00830

git checkout 788258e49 检出指定版本
git show HEAD 显示HEAD 版本信息

1. 检查最新commit 
    The status of pipelines, one of: running, pending, success, failed, canceled, skipped
    若 CI status 是success 则checkout commit
    running, pending 则 等待

2. 拉取后检查 composer.json 以及 composer.lock 是否变动 变动后自动执行composer install 命令
     若是测试环境 composer install --no-dev

"""
def get_logger(logfilename, loggerName=None, colored=True):
    '''
    When a logger is created, the level is set to NOTSET (which causes all messages to be processed when the logger is the root logger, 
    or delegation to the parent when the logger is a non-root logger). 
    Note that the root logger is created with level WARNING.
    
    CRITICAL	50
    ERROR	40
    WARNING	30
    INFO	20
    DEBUG	10
    NOTSET	0
    
    '''
    stream_formatter = logging.Formatter('%(name)-4s: %(levelname)-4s %(message)s')    
    if colored:
        try:
            from colorlog import ColoredFormatter #window下必须 在 getLogger之前导入 否则乱码
            LOGFORMAT = '%(log_color)s%(name)-4s%(reset)s: %(log_color)s%(levelname)-4s %(message)s%(reset)s'
            stream_formatter = ColoredFormatter(LOGFORMAT)
        except ImportError:
            pass
            
    rlog=logging.getLogger(loggerName)
    rlog.setLevel(logging.DEBUG)
    
    #stream
    stream = logging.StreamHandler(sys.stdout)
    stream.setFormatter(stream_formatter)
    stream.setLevel(logging.NOTSET)
    rlog.addHandler(stream)
    
    #file
    _file_handler = logging.FileHandler(logfilename,'a')
    _file_handler.setLevel(logging.NOTSET)
    _file_handler.setFormatter(logging.Formatter('%(asctime)s - %(name)-4s: %(levelname)s: %(message)s'))
    rlog.addHandler(_file_handler)
     
    return rlog 

class ExitException(Exception):
    """输出错误信息并退出"""

class GitlabApi(object):
    def __init__(self, domain, token):
        self.domain = domain
        self.apibase = '%s/api/v4'%(domain )
        self.token = token
    def request(self, api, params=None):
        headers = {'Private-Token':self.token}
        VERIFY_HTTPS = True
        url = self.apibase + api
        rlog.debug(url)
        resp = requests.get(url, headers=headers, params=params, timeout=5, verify=VERIFY_HTTPS)  
        return resp.json()

    def get_project(self, project_name):
        return Project(project_name, self)

def localtime(format = '%Y-%m-%d %H:%M:%S'):
    struct_t = time.localtime() #本地时区的时间
    return time.strftime(format, struct_t)

class Project(object):
    def __init__(self, name, api):
        self.name = name
        self.api = api

    def get_url(self):
        return self.api.domain+'/'+self.name

    def request(self, api, params={}):
        _name = quote(self.name, safe='')
        return self.api.request('/projects/%s%s'%(_name, api), params = params)

    def get_commits(self, ref_name=None):
        api = '/repository/commits'
        params = {}
        if ref_name:
            params['ref_name'] = ref_name
        return self.request(api, params = params)
    def get_commit(self, commit):
        """
        GET /projects/:id/repository/commits/:sha
        """
        api = '/repository/commits/%s'%(commit['id'])
        return self.request(api)
    def get_lastest_commit(self, ref_name=None):
        
        commit =  self.get_commits(ref_name)[0]
        return self.get_commit(commit)

    def get_pipeline(self, id):
        #GET /projects/:id/pipelines/:pipeline_id
        api = '/pipelines/%s'%id
        return self.request(api)

class Deployer(object):
    def __init__(self, config):
        """
        Args:
            config: Config
        """
        self.host = config.get_host()
        self.token = config.get_token()
        self.name = config.get('name')
        self.config = config

        self.repo_dir = config.get('local')
        self.branch = config.get('branch')

        self.api = GitlabApi(self.host, self.token)
        self.project = self.api.get_project(self.name)
        self.rep = self.get_rep()

        self.lastest_commit = None #dict 最新的commit
        self.last_pipeline = None #dict 
        self.status = None
        
        self.force = False #强制部署
        
    def __get_url(self):
        return '%s/%s'%(self.host,self.name)
        
    def get_last_deploy_commit(self):
        """
        获取 上次deploy 的 commit 信息
        """
        _commit = self.config.get('last_deploy_version')
        if not _commit:
            return None
        
        commit = self.rep.commit(_commit)
        return commit

    def set_last_deploy_commit(self, commit):
        _msg = commit.message.split('\n')[0]
        rlog.info('[%s] set last_deploy_version %s, "%s"'%(self.name, commit.hexsha, _msg))
        self.config.set('last_deploy_version', commit.hexsha)
        self.config.set('last_deploy_time', localtime())
        self.config.set('last_deploy_msg', _msg)
        self.config.save()

    def start(self):
        if self.status == 'wait':
            #等待 lastest_commit 的pipeline 处理完成， 才会继续query处理最新的commit
            self._step2()
            return
        # 获取目标分支最新的commit    
        lastest_commit = self.project.get_lastest_commit(self.branch)
        pprint(lastest_commit)
        lastest_msg = lastest_commit['title'].split('\n')[0]
        rlog.debug('[%s] remote branch[%s] lastest commit is %s; "%s"'%(self.name, self.branch, lastest_commit['short_id'], lastest_msg))
        local_commit = self.rep.commit()
        #pprint(dir(local_commit))

        rlog.debug('[%s] local commit is %s; %s'%(self.name, local_commit.hexsha, local_commit.message.split('\n')[0]))

        if local_commit.hexsha == lastest_commit['id'] and not self.force:
            #远程最新commit 和本地一致 则说明已经部署
            rlog.debug('[%s] has deploy lastest version %s (%s), "%s"'%(self.name, lastest_commit['id'], lastest_commit['committed_date'], lastest_msg))
            return
        last_pipeline = lastest_commit.get('last_pipeline')
        if last_pipeline is None:
            rlog.debug('[%s] no pipeline'%(self.name))
            self.checkout(lastest_commit)
            return
        if last_pipeline['status'] in ('running', 'pending'):
            self.last_pipeline = last_pipeline
            self.lastest_commit = lastest_commit
            self.status = 'wait'
            rlog.info('[%s] waiting pipeline finished'%(self.name))
            return 
        elif last_pipeline['status'] == 'success':
            self.checkout(lastest_commit) 
        else:
            rlog.warning(f"{self.name} last pipeline.status is {last_pipeline['status']}")
    def _step2(self):
        """
        检查 pipline 的状态 是否 success
        """
        pipeline_id = self.last_pipeline['id']
        pipeline = self.project.get_pipeline(pipeline_id)
        pprint(pipeline)
        if pipeline['status'] in ('running', 'pending'):
            rlog.info('[%s] pipeline %s status is %s, continue waiting'%(self.name, pipeline_id, pipeline['status']))
            return
        elif pipeline['status'] == 'success':
            rlog.info('[%s] pipeline %s status is %s'%(self.name, pipeline_id, pipeline['status']))
            self.checkout(self.lastest_commit) 
            self.status = ''
        else: 
            #CI没有通过
            self.status = ''
            rlog.warn('[%s] pipeline %s status is %s; weburl:%s'%(self.name, pipeline_id, pipeline['status'], pipeline['web_url']))

    def get_rep(self):
        project_url =  self.project.get_url()
        repo_dir = self.repo_dir
        if not os.path.exists(os.path.join(repo_dir, '.git')):
            Repo.clone_from(project_url, repo_dir)
        rep = Repo(repo_dir)
        return rep

    def shell(self, args):
        os.chdir(self.repo_dir)
        rlog.debug('[%s] set cwd: %s'%(self.name, self.repo_dir))
        rlog.debug('[%s] Popen(%s)'%(self.name, args))
        pipe = subprocess.Popen(args, stdout=subprocess.PIPE, stderr=subprocess.PIPE)
        while True:
            line= pipe.stdout.readline()
            if line == '':
                break
            print(line.rstrip())
            if pipe.poll():
                if pipe.returncode > 0:
                    raise Exception('%s returncode is %s'%(args, pipe.returncode))
        print(pipe.poll())
        return pipe.returncode
    def run_composer(self):
        sh = ['composer', 'install', '--no-progress']
        
        _sh =  self.config.get('sh_composer', False)
        if _sh:
            sh = _sh.split(' ')
        return self.shell(sh)
    def checkout(self, commit):
        """
        指定 ssh pubkey
        ssh_cmd = 'ssh -i id_deployment_key'
        with repo.git.custom_environment(GIT_SSH_COMMAND=ssh_cmd):
            repo.remotes.origin.fetch()
        or 
        repo.git.custom_environment(GIT_SSH_COMMAND=ssh_cmd) ?
        Set environment variables for future git invocations. Return all changed values in a format that can be passed back into this function to revert the changes
        """
        
        short_id = commit['short_id']

        
        last_deploy_commit = self.get_last_deploy_commit()
        
        # 先拉取最新的代码到本地,否则 checkout的时候本地没有该commit
        # 但是这里可能会引起另外的问题，在checkout 目标commit前这段时间代码是最新的
        rlog.debug('pull from remote %s'%self.branch)
        self.rep.git.checkout(self.branch)
        self.rep.git.pull() 

        rlog.info('checkout to commit:%s, "%s"'%(short_id, commit['title'].split('\n')[0])) 
        print(self.rep.git.checkout(short_id))
        cur_commit = self.rep.commit()
        assert(cur_commit.hexsha == commit['id'])
        print('cur_commit:%s'%cur_commit)
        print('last_deploy_commit:%s'% last_deploy_commit)
        
        run_composer = False
        if last_deploy_commit:
            for diff in cur_commit.diff(last_deploy_commit):
                #print '--------'
                #print(dir(diff))
                #print diff
                if diff.a_path.startswith('composer.'):
                    rlog.info('[%s] detected %s changed'%(self.name, diff.a_path))
                    run_composer = True
        _msg = u'[%s|%s] deployed'%(self.__get_url(), self.name)
        _msg+='\nbranch:%s'%self.branch
        if run_composer:
            self.run_composer()
            _msg+='\ncomposer install'
        self.set_last_deploy_commit(cur_commit)
        _msg+='\n commit:%s'%(cur_commit.message.split('\n')[0])
        self.send_msg(_msg)

    def send_msg(self, msg):
        if self.config.get('worktile_users'):
            apiurl = self.config.get('url', section_name='worktile')
            headers = {'Content-type': 'application/json', 'Accept': 'text/plain'}
            if not apiurl:
                raise ExitException('worktile hookmsg url not configed')
            users = self.config.get('worktile_users').split(',')
            for user in users:
                data = {'user': user, 'text':msg}
                rlog.info('notify %s'%user)
                res = requests.post(apiurl, data=json.dumps(data), headers=headers).json()
                rlog.debug('notify%s result: %s'%(user, res.get('message')))

class Config(object):
    cf = configparser.ConfigParser()
    def __init__(self, config_file, section_name):
        self.config_file = config_file
        self.section_name = section_name
        

    def get_host(self):
        return self.cf.get('global', 'host')
    def get_token(self):
        return self.cf.get('global', 'token')

    def get(self, name, default=None, section_name=None):
        _section_name = self.section_name
        if section_name:
            _section_name = section_name
        try:
            return self.cf.get(_section_name, name)
        except configparser.NoOptionError:
            return default

    def set(self, name, val):
        return self.cf.set(self.section_name, name, val)

    def save(self):
        with open(self.config_file, "w", encoding='utf8') as handle:
            self.cf.write(handle)
        rlog.debug('write config to file %s'%self.config_file)

    @classmethod    
    def get_sites(cls, config_file):
        rlog.debug('read config from %s'%config_file)
        cls.cf.read(config_file, encoding='utf8')
        _all = []
        for section_name in cls.cf.sections():
            if not section_name.startswith('git-'):
                continue
            _all.append(cls(config_file, section_name))
        return _all

    @classmethod
    def interactive_init_config(cls, config_file):
        cf = cls.cf
        if os.path.isfile(config_file):
            cf.read(config_file, encoding='utf8')
            if cf.has_section('global') and cf.get('global', 'host'):
                raise ExitException('config file has been initialized!!')
        section = 'global'        
        cf.add_section(section)
        host = input('GitLab instance URL. e.g. https://gitlab.com\n')
        cf.set(section, 'host', host)
        token = input('Your gitlab personal token, %s\n'%host)
        cf.set(section, 'token', token)
        print("""config info:
        [host]
        host = %s
        token = %s
        """%(host, token))
        cf.write(open(config_file, "w"))
        
    @classmethod
    def interactive_add_site(cls, config_file):
        rlog.debug('read config from %s'%config_file)
        cls.cf.read(config_file, encoding='utf8')
        cf = cls.cf
        _name = input('input sitename, must unique\n')
        assert(_name)
        section = 'git-'+_name
        if cf.has_section(section):
            raise ExitException(u'the section [%s] is duplicate'%_name)
        name = input('input remote project name, e.g. [useranme]/[project_name]\n')
        assert(name.find('/')> 0)
        cf.add_section(section)
        cf.set(section, 'name', name)
        repo_dir = input('input local repo directory path(absolute path)\n')
        cf.set(section, 'local', repo_dir)

        branch = input('input branch (master default)\n')
        if not branch:
            branch = 'master'
        cf.set(section, 'branch', branch)
        cf.set(section, 'last_deploy_version', '')
        cf.set(section, 'last_deploy_time', '')
        print("""config info:
        [git-%s]
        name = %s
        local = %s
        branch = %s
        """%(_name, name, repo_dir, branch))
        cf.write(open(config_file, "w", encoding='utf8'))
    @classmethod
    def list_siteinfo(cls, config_file):
        for config in Config.get_sites(config_file):
            print('[%s]\n%s\t%s  '%(config.section_name, config.get('name'), config.get('branch'))) 
            print('time:%s'%config.get('last_deploy_time'))
            print('commit:%s'%config.get('last_deploy_msg'))
            print('')
            
class Main():
    def __init__(self, config_file):
        self.deployers = {}
        self.config_file = config_file

    def get_deployer(self, config):
        section = config.section_name
        if self.deployers.get(section):
            return self.deployers.get(section)
        deployer = Deployer(config)
        self.deployers[section] = deployer
        return deployer

    def start(self, sitename = None, force_deploy = False):
        hasconfig = False
        for config in Config.get_sites(config_file):
            if sitename and sitename != config.section_name.replace('git-', ''):
                continue
            print('')
            hasconfig = True
            rlog.info('[%s] %s:%s checking'%(config.section_name, config.get('name'), config.get('branch'))) 
            deployer = self.get_deployer(config)
            deployer.force = force_deploy
            deployer.start()
        if sitename and hasconfig is False:
            print(Fore.RED+f"site:[{sitename}] not exits in config")

    def start_loop(self):
        while True:
            sleep = 10
            try:
                self.start()
            except requests.exceptions.RequestException as e:
                rlog.error('requests exception: %s'%e)
                sleep = 30
            rlog.debug('sleep %ds'%sleep)
            time.sleep(sleep)        

version= '0.2.0'
script_path=sys.argv[0]
rlog = get_logger('deploy.log')
 
parser = argparse.ArgumentParser()
parser.add_argument('-v','--version', action='version', version=version, help="show program's version number and exit")
parser.add_argument('config_file', default='deploy.ini', nargs='?',  help=u'配置文件路径')
parser.add_argument('-c', '--config_name', default=None, nargs='?',  help=u'配置文件名称')
parser.add_argument('-s', '--sitename', default=None, nargs='?',  help=u'站点名称')
parser.add_argument('-l', '--list', default=False, action='store_true', help=u'输出所有site部署信息')
parser.add_argument('-A', '--add', default=False, action='store_true', help=u'新增一个配置项')
parser.add_argument('--init', default=False, action='store_true', help=u'新增配置文件')
parser.add_argument('--loop', default=False, action='store_true', help=u'循环检查')
parser.add_argument('--force', default=False, action='store_true', help=u'强制部署最新')

args = parser.parse_args()
config_file = args.config_file

if args.config_name:
    config_file=os.path.join(os.path.dirname(script_path),'config-%s.ini'%(args.config_name))
config_file = os.path.abspath(config_file)

try:
    if args.add:
        Config.interactive_add_site(config_file)
        quit()
    if args.init:
        Config.interactive_init_config(config_file)
        quit()
    if args.list:
        Config.list_siteinfo(config_file)
        quit()
    main = Main(config_file)
    if args.loop:
        main.start_loop()
    else:    
        main.start(sitename=args.sitename, force_deploy=args.force)

except KeyboardInterrupt:
    rlog.info('user press CTRL+C then exit')
except ExitException as e:
    rlog.error(e)
    sys.exit(1)
